caddyhttp, reverseproxy: experimental WebTransport passthrough#7669
caddyhttp, reverseproxy: experimental WebTransport passthrough#7669tomholford wants to merge 16 commits intocaddyserver:masterfrom
Conversation
Add github.com/quic-go/webtransport-go dep (built on quic-go, same maintainers) and call webtransport.ConfigureHTTP3Server on the http3.Server. This advertises WebTransport enablement in SETTINGS, enables HTTP/3 DATAGRAMs, and stashes the *quic.Conn in each request's context — a prerequisite for a later WebTransport-aware handler or reverse-proxy transport to call webtransport.Server.Upgrade. Also enable QUIC stream reset partial delivery, required by webtransport-go. No user-visible behavior change: clients that don't speak WebTransport ignore the extra SETTINGS, and no handler yet calls Upgrade. Extract the http3.Server construction into buildHTTP3Server so the SETTINGS assertions can be unit-tested without a live UDP listener.
Go's type assertion `x.(T)` does not follow Unwrap() http.ResponseWriter chains. Caddy wraps the writer multiple times (logging recorder, intercept, encode, etc.), so code that needs interfaces implemented only by the raw writer owned by the HTTP server — for example the http3.Settingser/HTTPStreamer interfaces that webtransport.Server.Upgrade type-asserts — cannot see through those wrappers. UnwrapResponseWriterAs walks the Unwrap() chain and returns the first writer that satisfies the requested interface (or the zero value if none do). Mirrors the traversal http.ResponseController performs internally. Used by upcoming WebTransport handler and reverse-proxy transport.
Introduces http.handlers.webtransport, an EXPERIMENTAL handler that
terminates an incoming WebTransport session on top of Caddy's HTTP/3
server and echoes bytes on each bidirectional stream. Primary use case
is as a test upstream for the forthcoming WebTransport reverse-proxy
transport; it also serves as the minimal proof that the server-side
WebTransport wiring works end-to-end.
Plumbing changes:
* caddyhttp.Server gains a *webtransport.Server field alongside
h3server. It's built in buildWebTransportServer(), wrapping the
existing http3.Server. Exposed via WebTransportServer() any on the
Server, so the caddyhttp public API does not name the upstream
webtransport-go type (per AGENTS.md).
* serveHTTP3 now runs a custom accept loop (serveH3AcceptLoop) that
dispatches each accepted QUIC connection to
webtransport.Server.ServeQUICConn instead of
http3.Server.ServeListener. The WebTransport server transparently
forwards non-WT streams to the underlying http3 request handler
(cost: one varint peek per stream), so behavior for non-WT clients
is unchanged.
* ListenQUIC enables EnableDatagrams and
EnableStreamResetPartialDelivery on the QUIC listener config.
These are capability bits negotiated during the QUIC handshake and
are prerequisites for any WebTransport session; they do not force
usage so non-WT H3 traffic is unaffected.
* Stop path closes wtServer after h3server.Shutdown to clean up any
remaining WebTransport session state.
The handler uses caddyhttp.UnwrapResponseWriterAs to reach the naked
http3.Settingser/HTTPStreamer writer through Caddy's wrapping chain
before calling webtransport.Server.Upgrade.
Includes unit tests for request-shape detection plus an integration
test (caddytest/integration/webtransport_test.go) that spins up a
Caddy HTTP/3 server with the handler, dials it with a real
webtransport.Dialer, and asserts end-to-end bidirectional-stream
echo.
dialUpstreamWebTransport is a thin wrapper around webtransport.Dialer.Dial that sets the QUIC config flags WebTransport requires (EnableDatagrams, EnableStreamResetPartialDelivery) and forwards request headers on the Extended CONNECT. Intended as an internal building block for the upcoming WebTransport reverse-proxy transport; not yet wired into ServeHTTP. Unit-tested against an in-process webtransport.Server with a freshly minted self-signed certificate. Covers: successful dial, header forwarding, and connection-refused against an unbound port.
runWebTransportPump bridges two WebTransport sessions so every
bidirectional stream, unidirectional stream, and datagram opened on one
side is mirrored on the other. Uses six goroutines (bidi both ways, uni
both ways, datagrams both ways) and blocks until both sessions end.
Close propagation: when either session ends, the peer is closed via
CloseWithError. The code/message are read from the closing session's
stored close state (by probing AcceptStream with a short timeout),
since Receive{Datagram,UniStream} return the underlying stream error
rather than the SessionError and can win the propagation race. Close
propagation is best-effort for client-initiated close through a
Dialer-dedicated QUIC conn: webtransport-go tears down the QUIC
connection immediately after CloseWithError, so the upstream may
observe a QUIC ApplicationError before the WT_CLOSE_SESSION capsule is
parsed. The pump still closes the peer session; only the specific
error code may not survive.
Not yet wired into ServeHTTP.
Tests: topology of client -> frontend -> upstream where frontend runs
the pump. Exercises bidi both ways, uni client-to-upstream, datagram
round-trip, CloseWithError propagation both ways, and a basic
goroutine-leak check.
Extends the http reverse-proxy transport with a webtransport boolean
that opts the upstream into WebTransport passthrough. Must be combined
with versions: ["3"]; WebTransport rides on HTTP/3 exclusively.
When enabled, Handler.ServeHTTP detects Extended CONNECT with
:protocol=webtransport early — before any of the normal round-trip
machinery — and branches to serveWebTransport, which:
1. Pulls the *webtransport.Server off caddyhttp.Server (via
WebTransportServer()) and errors out cleanly if HTTP/3 isn't
enabled on the frontend.
2. Picks a single upstream through the configured load-balancer.
No retries: a failed dial closes the client session and returns.
3. Walks the response-writer Unwrap() chain to reach the raw http3
writer and calls webtransport.Server.Upgrade to terminate the
incoming session.
4. Uses dialUpstreamWebTransport to open a session to the selected
upstream, forwarding request headers on the Extended CONNECT.
5. Runs runWebTransportPump between the two sessions and blocks
until both close.
The transport's wtTLSConfig is built at Provision time from the
existing TLS config (same path h3Transport already uses) and reused
for every session.
Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a
single Caddy instance with two HTTP/3 servers — one proxy on :9443,
one terminating echo upstream on :9444 — and drives a real
webtransport.Dialer through the proxy to assert end-to-end
bidirectional-stream echo.
Adds a `webtransport` subdirective to the `transport http {}` block of
reverse_proxy that sets the new WebTransport bool on the transport.
Takes no arguments; exclusivity with versions 3 is enforced at
Provision time so parse order doesn't matter.
Example:
reverse_proxy https://backend:9443 {
transport http {
versions 3
webtransport
tls_insecure_skip_verify
}
}
Includes a Caddyfile-to-JSON adapt test round-tripping the new
subdirective.
The WebTransport proxy path previously bypassed the request-preparation
pipeline that normal reverse-proxy traffic runs through. Reuse it so
`header_up`, `X-Forwarded-For`/`Host`/`Proto`, `Via`, `Rewrite`, the
`{http.reverse_proxy.upstream.*}` placeholders, dynamic upstreams,
`countFailure`, and the `{http.reverse_proxy.duration{_ms}}` timing
placeholder all behave the same as on the regular path.
Retries, `handle_response`, and response-header ops are intentionally
not run here — a WebTransport session has no HTTP response body to
post-process and is not idempotent. Integration test exercises the
header-forwarding contract end-to-end through a standalone (non-Caddy)
WebTransport upstream so the forwarded Extended CONNECT can be
inspected.
Reorder serveWebTransport so the upstream is dialed first. If the upstream is unreachable or refuses the CONNECT, a proper 5xx is returned from the handler — the client's Dial() surfaces the real status instead of a successful upgrade followed by an opaque session close. Also apply `h.Headers.Response` (gated by `Require`, if configured) against the upstream response status/headers; the ops run on the client-visible response headers, which webtransport.Server.Upgrade flushes with the 200 OK. If the client-side upgrade fails after the upstream dial succeeded, close the upstream session cleanly. Integration test drives a dial to an unbound loopback port and asserts the client sees a 5xx status instead of a bare session close.
Bracket the pump's lifetime with Host.countRequest(±1) and incInFlightRequest/decInFlightRequest so WT sessions participate in the same accounting as the normal proxy path: - MaxRequests gating (Upstream.Full) now blocks WT sessions past the cap, instead of silently failing open. - LeastConn / FirstAvailable selection sees WT load, instead of seeing busy upstreams as idle. - Admin /reverse_proxy/upstreams reports WT sessions under num_requests. Integration test holds an upstream session open via a standalone WT server, polls the admin API to assert num_requests increments during the session and drops back to 0 after close.
|
@marten-seemann This wouldn't have been possible without your hard work on |
|
|
||
| func (p *webtransportPump) run() { | ||
| var wg sync.WaitGroup | ||
| wg.Add(6) |
There was a problem hiding this comment.
That's a lot of goroutines to spin up for a single request. Do we really need 6? That seems wild, is it not possible to just have two?
There was a problem hiding this comment.
These six are per-session, not per-stream, and fall out of the webtransport-go API shape. The library exposes three independent blocking accept methods — Session.AcceptStream (bidi), Session.AcceptUniStream (uni), and Session.ReceiveDatagram — each of which must be driven from its own goroutine because Go has no non-blocking variant to select over. Streams and datagrams can originate from either peer, so each direction needs its own accept loop: 3 × 2 = 6.
A WT session that serves thousands of streams still has only these 6 session-level goroutines, plus the per-stream splices (2 per active bidi stream, 1 per active uni stream).
I tried collapsing the pairs into selects fed by smaller goroutines, but that pushes the same six goroutines one level down for no runtime saving and more plumbing. Happy to revisit if webtransport-go upstream adds a unified accept API.
There was a problem hiding this comment.
So you're saying for each reverse_proxy instance there will only be 6 goroutines? or 6 per upstream? instead of 6 per request I think. I'm not sure what a "session" means in this context.
If that's the case, that seems pretty reasonable. I just wanted to make sure goroutine count is kept in check cause it can mean a lot of memory buildup if not managed well (as we're trying to improve in #7649).
There was a problem hiding this comment.
Good question. To clarify:
6 per active WebTransport session. Not per reverse_proxy instance, upstream, nor request.
"Session" is the IETF's own term (see draft-ietf-webtrans-http3 §3): it's established when the server sends a 2xx to an Extended CONNECT with :protocol=webtransport, and it's the single long-lived handle over which any number of bidirectional streams, unidirectional streams, and datagrams flow in either direction.
There was a problem hiding this comment.
So it's 6 goroutines per connected client? That's still a lot, effectively 3x as heavy as websockets basically. Seems strange. (Just trying to understand the practical semantics.) I would still call that "request" though I realize because of UDP it's looser but it's still the same idea I think, each client would almost always just do one request to set up a websocket, like they would do just one to set up the webtransport, no?
|
Well this is a bit scary from a blast-radius perspective. Francis seems to be covering the implementation and maintenance issues well already, my separate concern is the architectural one. This appears to put experimental WebTransport handling directly into Caddy’s core HTTP/3 accept path, not just behind an opt-in reverse-proxy feature. That makes me think this wants splitting or at least a much stronger justification for why the core H3-path change is low risk. Right now it looks like an experimental feature is being paid for by every HTTP/3 deployment, not only the ones that opt into WebTransport. |
|
Thank you for the feedback. Will review and address. |
The WebTransport proxy path in serveWebTransport duplicated the
dynamic-upstream-fallback block and the {http.reverse_proxy.upstream.*}
replacer-variable block from proxyLoopIteration. Francis flagged this
as a maintenance burden in review of caddyserver#7669.
Extract two helpers:
* resolveUpstreams(r) returns the candidate upstream set — dynamic
when configured (with provisioning + fallback-on-error), static
otherwise. Caller runs the LB selection policy, since the two call
sites diverge on how selection failure is reported (retry loop vs.
fast 502 for long-lived WT sessions).
* setUpstreamReplacerVars(repl, up, di) publishes the seven
placeholders describing the selected upstream.
Both are used by proxyLoopIteration and serveWebTransport with
identical semantics to the inlined code they replace. No behavior
change for either path.
Francis pointed out in review of caddyserver#7669 that importing the whole modules/caddyhttp/webtransport package solely to pull in one constant and one interface wasn't worthwhile. Move both into webtransport_transport.go as unexported identifiers (webtransportProtocol, webtransportWriter). This removes reverseproxy's dependency on the caddywt package and clears the way for moving the echo handler itself out of the production module tree. No behavior change.
Francis pointed out in review of caddyserver#7669 that the echo handler — which exists solely as a test upstream for the WebTransport reverse-proxy tests — should not be a full-fledged module registered in every Caddy binary. Mirroring the mockdns_test.go pattern, move it into a _test.go file under caddytest/integration/. The module ID http.handlers.webtransport is now registered only when the integration test binary is built, which is when caddytest/integration/webtransport_test.go references it by ID string in its JSON configs. Production Caddy builds no longer include it. Changes: * New file: caddytest/integration/webtransport_echo_test.go — contains the WebTransportEcho handler, its types and interface guards, the isWebTransportEchoUpgrade helper, and the unit tests that used to live in the deleted package's handler_test.go. * Deleted: modules/caddyhttp/webtransport/ (handler.go + handler_test.go). * Removed the blank import from modules/caddyhttp/standard/imports.go. The Protocol const and Writer interface that this package used to export were inlined into reverseproxy's own files in a preceding commit, so nothing else depends on the deleted package.
… server flag steadytao raised an architectural concern in review of caddyserver#7669: the PR put experimental WebTransport handling directly into Caddy's core HTTP/3 accept path, so every HTTP/3 deployment paid for the feature whether or not they used it. Collapse the enablement surface to a single server-level opt-in that matches Caddy's existing precedent for protocol-level features (`protocols`, `allow_0rtt`, `enable_full_duplex`), and detect the request shape at the handler the same way `reverse_proxy` detects a WebSocket upgrade today — no per-handler config flag. Core HTTP/3 path changes (modules/caddyhttp/server.go): * New `EnableWebTransport bool` field on Server, marked EXPERIMENTAL. * buildHTTP3Server now only calls webtransport.ConfigureHTTP3Server and sets EnableStreamResetPartialDelivery when the flag is true. When false, the constructed http3.Server is bit-for-bit identical to the pre-WebTransport implementation. * wtServer is constructed only when the flag is true. * serveH3AcceptLoop falls back to http3.Server.ServeListener when the flag is false — no varint peek, no per-connection dispatch. Caddyfile wiring (caddyconfig/httpcaddyfile/serveroptions.go): * New `enable_webtransport` global server option, modeled on `enable_full_duplex`. Reverse-proxy simplifications (modules/caddyhttp/reverseproxy/): * Removed HTTPTransport.WebTransport field and its Provision-time exclusivity check (no longer needed; H3 is validated separately). * Removed the `webtransport` Caddyfile subdirective under `transport http { }` — this neutralizes the prior commit that introduced it. * Removed Handler.webtransportEnabled cache. ServeHTTP now branches on isWebTransportExtendedConnect(r) alone, matching how the WebSocket upgrade branch works. * serveWebTransport gains fail-fast guards with clear errors when the parent server has enable_webtransport=false or when the handler's transport does not include HTTP/3. Tests: * Existing TestServer_BuildHTTP3ServerEnablesWebTransport now sets EnableWebTransport=true explicitly; new TestServer_BuildHTTP3ServerWithoutWebTransport locks in the regression guard that flag-off produces the pre-PR http3.Server. * Integration tests updated: enable_webtransport: true added to every H3 server block; "webtransport": true dropped from the reverse_proxy transport JSON (auto-detected now). * Caddyfile adapt test for the deleted `webtransport` subdirective is removed; `enable_webtransport` is added to the existing global_server_options_single adapt test alongside its peers. No runtime behavior change when enable_webtransport is false. Diff against master on the core HTTP/3 path is effectively zero in that configuration.
…bTransport Adds a hermetic pair of benchmarks on buildHTTP3Server to provide quantitative evidence for the claim that deployments with enable_webtransport=false pay no cost for the WebTransport feature. Results on Apple M4, go1.25, -count=5: BenchmarkBuildHTTP3Server_WebTransportOff ~70 ns/op 392 B/op 3 allocs/op BenchmarkBuildHTTP3Server_WebTransportOn ~144 ns/op 600 B/op 6 allocs/op The Off path is about half the cost on every dimension, confirming that the work skipped when the flag is false is the webtransport.ConfigureHTTP3Server call plus EnableStreamResetPartialDelivery. Absolute cost is a one-time per-server setup so either branch is negligible in practice, but the asymmetry locks in a regression guard: a future refactor that accidentally re-enables the WT configuration unconditionally would show up as a jump in the Off numbers. This benchmark does not exercise the per-stream dispatch cost inside webtransport.Server.ServeQUICConn — that would require a full QUIC setup to measure in isolation and is follow-up work.
|
@steadytao Thanks for flagging; I've pushed commits that fully address this: bb8b3ee ( Same commit also drops the per-handler {
servers {
enable_webtransport
}
}
example.com {
reverse_proxy https://backend:9443 {
transport http {
versions 3
}
}
}d67425d adds a pair of hermetic benchmarks to quantify the delta: Off is about half the cost on every dimension, which is exactly the work skipped when the flag is false ( The |
|
@francislavoie @steadytao ready for re-review when you have time. Summary of what landed since the last pass:
|
|
I will allow francis to continue with the review. But from a brief checkover, it seems much better, thank you! |
The WT path duplicated upstream resolution, LB selection, header ops,
replacer vars, and in-flight counters. Route WT through the shared
ServeHTTP -> proxyLoopIteration -> reverseProxy flow and swap RoundTrip
for a small webTransportHijack that only does WT-specific work (writer
unwrap, upstream dial, client upgrade, pump).
Rename roundtripSucceededError -> terminalError. The existing name
described when it was emitted (after a successful round-trip); the
new name describes its contract with the retry loop (stop looping,
propagate error unchanged). The WebTransport upgrade case is a second
natural caller for that same signal.
Comes with two behavior improvements that fall out of the collapse:
- WT upstream dial failures now surface as DialError, so the loop
can fail over across upstreams like normal proxies (today: 502).
- Passive health checks apply to WT dials (dial-failure countFailure
and UnhealthyLatency on dial duration) via the shared path.
Addresses reviewer feedback that the duplicated setup was a
maintenance risk.
| // A WT CONNECT reached this handler because the parent server has | ||
| // enable_webtransport=true. But the handler's transport still has to | ||
| // speak HTTP/3 to dial the WT upstream. | ||
| ht, ok := h.Transport.(*HTTPTransport) |
There was a problem hiding this comment.
Better to create an interface and implement that interface for HTTPTransport instead of type assertion.
| // own type assertions and cannot see past a wrapper. | ||
| func UnwrapResponseWriterAs[T any](w http.ResponseWriter) (T, bool) { | ||
| var zero T | ||
| for w != nil { |
There was a problem hiding this comment.
Non nil check seems unnecessary, same for the equality check below for unwrap.
| return copyMap | ||
| } | ||
|
|
||
| // resolveUpstreams returns the candidate upstream set for this request: |
There was a problem hiding this comment.
Why are these functions created when they are not used else where?
i don't think reducing the lines of the original function will affect its readability. As both are simple enough with comments.
| // WebTransportServer returns the server's underlying WebTransport | ||
| // serving state as an opaque value. Modules that import | ||
| // github.com/quic-go/webtransport-go may type-assert it to | ||
| // *webtransport.Server. Returns nil if HTTP/3 is not in use. |
There was a problem hiding this comment.
nil if web transport is not enabled.
| // EXPERIMENTAL: this helper is an internal building block for the | ||
| // WebTransport reverse-proxy transport and may change. | ||
| func runWebTransportPump(clientSess, upstreamSess *webtransport.Session, logger *zap.Logger) { | ||
| if logger == nil { |
There was a problem hiding this comment.
Remove this nil check. Currently in both testing and production a non nil logger is passed. Create a new logger when nil is passed may lead to problems later on.
| var wg sync.WaitGroup | ||
| wg.Add(6) | ||
|
|
||
| // Bidirectional streams in both directions. |
There was a problem hiding this comment.
Use wg.Go, this was introduced in 1.25 and the minimal go version caddy supports.
Summary
Revives #5421. Adds experimental WebTransport (draft-ietf-webtrans-http3) reverse-proxy passthrough to
reverse_proxy'shttptransport. HTTP/3 Extended CONNECT with:protocol=webtransportis upgraded client-side, re-dialed upstream, and the two sessions are bridged (bidirectional streams, unidirectional streams, and datagrams in both directions).Motivation
#5421 documented that Caddy couldn't proxy WebTransport and was closed pending HTTP/3 upstream support (which landed experimentally May 2024 via #5086). w3c/webtransport#525 confirms (per the IETF WG chair) there is no transport-level shortcut — a proxy must understand WT framing. The core team has said community contributions are welcome, and browser support for WebTransport is now broad.
User-facing
Enable WebTransport on the HTTP/3 server with one server-level directive — the same shape as
protocols,allow_0rtt, orenable_full_duplex.reverse_proxyauto-detects the WebTransport Extended CONNECT the same way it auto-detects a WebSocket upgrade today; no per-handler config needed.JSON:
{ "apps": { "http": { "servers": { "srv0": { "listen": [":443"], "enable_webtransport": true, "routes": [{ "handle": [{ "handler": "reverse_proxy", "transport": { "protocol": "http", "versions": ["3"], "tls": {} }, "upstreams": [{"dial": "backend:9443"}] }] }] } } } } }Caddyfile:
{ servers { enable_webtransport } } example.com { reverse_proxy https://backend:9443 { transport http { versions 3 } } }Implementation
Fifteen atomic commits (ten original + five in response to review); each compiles and passes its own tests:
caddyhttp:EnableWebTransportserver-level flag (opt-in,EXPERIMENTAL). When true, advertises WebTransport in HTTP/3 SETTINGS, enablesEnableDatagrams+EnableStreamResetPartialDeliveryon the QUIC listener config, and dispatches each QUIC connection throughwebtransport.Server.ServeQUICConn. When false, the HTTP/3 path is bit-for-bit identical to pre-WebTransport Caddy (falls back tohttp3.Server.ServeListener). No runtime cost for HTTP/3 deployments that don't opt in.caddyhttp:UnwrapResponseWriterAs[T]helper — Go's type assertionx.(T)doesn't followUnwrap() http.ResponseWriterchains, and webtransport-go'sUpgraderequires direct-assertinghttp3.Settingser/http3.HTTPStreameron the naked writer.caddyhttp(test-only): terminating WebTransport echo handler, registered ashttp.handlers.webtransportbut only in the integration test binary (caddytest/integration/webtransport_echo_test.go, mirroring themockdns_test.gopattern). Production Caddy builds don't include it.reverseproxy: upstream WT dialer, session pump (six goroutines for bidi/uni streams + datagrams in both directions), andServeHTTPbranch. WT is detected by request shape (:method=CONNECT,:protocol=webtransport) the same way WebSocket upgrades are detected — no per-handler flag.reverseproxyfeature parity (3 commits): (1) run the same request-preparation pipeline as the normal proxy path soheader_up,X-Forwarded-*,Via,Rewrite, upstream placeholders, dynamic upstreams,countFailure, and{http.reverse_proxy.duration{_ms}}all behave the same. (2) Dial the upstream before upgrading the client so an unreachable upstream surfaces as a 5xx on the client'sDial()instead of a bare post-upgrade session close — and soh.Headers.Responsecan be applied to the client-visible 200 OK. (3) Track active WT sessions inHost.NumRequests/ the in-flight counter soMaxRequestsgating, LeastConn/FirstAvailable LB, and the admin/reverse_proxy/upstreamsendpoint reflect WT load.Protocolconst +Writerinterface into the reverseproxy package; moved the echo handler out of the production module tree into the integration test binary; introduced theenable_webtransportserver flag (collapsing the earlier per-handler surface); added micro-benchmarks confirming the flag-off path is strictly cheaper than flag-on.Retries and
handle_responseare intentionally skipped — WT sessions are long-lived and there's no HTTP response body to post-process.Limitations / design notes (flagging for review)
quic-go/webtransport-gosupports at build time. Browser updates will require dep bumps. Surface isEXPERIMENTAL.github.com/quic-go/webtransport-go. Same maintainers as the existingquic-godependency; usesquic-go/http3internals we can't practically reimplement. Wrapped behind a Caddy-internalanyaccessor (Server.WebTransportServer()) socaddyhttp's public API does not name the upstream type, per AGENTS.md.enable_webtransport: false(the default) leaves the core HTTP/3 accept path, QUIC config, and SETTINGS advertisement identical to pre-PR Caddy. Thewebtransport-godep is still pulled in at build time; keeping it out entirely would require build tags, which isn't how Caddy gates features elsewhere.Dialertears down the dedicated QUIC connection immediately afterCloseWithError, racing theWT_CLOSE_SESSIONcapsule. The close itself propagates reliably; only the specific numeric code can be lost. upstream→client direction propagates codes reliably.io.Copy, which handles FIN but notRESET_STREAM+ code). App protocols that use reset codes for signaling (e.g. MoQ) would see graceful EOF instead. Follow-up.Dial()fails fast. WT sessions are long-lived; retry semantics are app-level. Matches HTTP spec expectations for WebSocket-like upgrades.StreamTimeoutdoes not apply to WT sessions. The normal path uses it to forcibly close streaming requests; for WT it's session-level and semantically different. Documented rather than forced to map. Follow-up if users need it.Test plan
modules/caddyhttpunit tests: WebTransport SETTINGS advertised on the builthttp3.ServerwhenEnableWebTransport=true, and confirmed absent when false (regression guard for the server-level gate);UnwrapResponseWriterAstraversal through single/multi wrappers + defensive self-reference handling.modules/caddyhttpmicro-benchmarks:BenchmarkBuildHTTP3Server_WebTransportOff(~70 ns/op, 392 B/op, 3 allocs/op on Apple M4) vs_On(~144 ns/op, 600 B/op, 6 allocs/op), confirming the flag-off path is strictly cheaper.caddytest/integrationecho-handler unit tests: request-shape detection, pass-through for non-WT requests.modules/caddyhttp/reverseproxyunit tests: dialer happy path + header forwarding + bad-address; pump bidi/uni/datagram round-trip, close-code propagation both directions, goroutine-leak sanity check.caddytest/integration: five tests covering the full matrix — (1) realwebtransport.Dialer→ Caddy H3 →webtransportecho handler asserting bidi echo; (2) real dialer → Caddy proxy → second Caddy upstream, bidi echo through the pump; (3) standalone-upstream test inspecting the forwarded Extended CONNECT, assertingheaders.request.set,X-Forwarded-For, andVia; (4) dial against an unbound port, asserting the client sees a 5xx status (not post-upgrade close); (5) active-session test that polls/reverse_proxy/upstreamsand assertsnum_requestsincrements during the session and drops to 0 after close.enable_webtransportglobal server option (covered by the updatedglobal_server_options_single.caddyfiletest).Assistance Disclosure
Claude collaborated on research, design, implementation, and tests; I reviewed each commit, exercised the code end-to-end against real binaries, and iterated on the design where I disagreed with generated output.
Appendix: manual end-to-end reproduction
The steps below reproduce the WT proxy end-to-end against a real
webtransport.Dialerclient hitting a real Caddy binary (not the in-processcaddytest.Tester). Because the echo handler now lives only in the integration-test binary, the upstream in this topology is a small standalonewt-echo-serverrather than a second Caddy instance.What it verifies
enable_webtransportis on.enable_webtransport: false, the core HTTP/3 path is unchanged — WT SETTINGS are not advertised and plain H3 behaves as on master.Build + config
/tmp/wt-echo-server/main.go— ~70-line standalone WT echo server (used as the upstream)/tmp/caddy-wt-proxy.json— proxy Caddy on :9443 forwarding to the echo upstream on :9444Uses the test cert Caddy's own integration tests ship with (
caddytest/a.caddy.localhost.crt). Adjust the absolute paths to match your checkout.{ "admin": {"listen": "localhost:2999"}, "apps": { "http": { "http_port": 9080, "https_port": 9443, "servers": { "proxy": { "listen": [":9443"], "protocols": ["h3"], "enable_webtransport": true, "routes": [{"handle": [{ "handler": "reverse_proxy", "transport": { "protocol": "http", "versions": ["3"], "tls": {"insecure_skip_verify": true} }, "upstreams": [{"dial": "127.0.0.1:9444"}] }]}], "tls_connection_policies": [{ "certificate_selection": {"any_tag": ["cert0"]}, "default_sni": "a.caddy.localhost" }] } } }, "tls": { "certificates": { "load_files": [{ "certificate": "<path-to-caddy-repo>/caddytest/a.caddy.localhost.crt", "key": "<path-to-caddy-repo>/caddytest/a.caddy.localhost.key", "tags": ["cert0"] }] } }, "pki": {"certificate_authorities": {"local": {"install_trust": false}}} } }/tmp/wt-poke/main.go— 112-line Go WebTransport smoke-test clientOpens N parallel bidirectional streams, writes an indexed payload on each, reads the echo, asserts equality, and reports per-stream timings.
Reproduction steps
Observed result (Apple Silicon, macOS, loopback)
Clean SIGTERM on both processes, no goroutine leaks, no port contention on tear-down.